package se.prb.cheesymock;

import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
import java.util.Arrays;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.log4j.Logger;

/**
 * <h2>Summary</h2>
 * <p>
 * CheesyMock is a very simple way to create mock objects from arbitrary
 * interfaces.
 * </p>
 * 
 * <h2>Usage</h2>
 * <p>
 * To create a new mock object that implements a given interface:
 * 
 * <pre>
 * MyInterface	mockObject	= (MyInterface) CheesyMock.createMock(MyInterface.class, null);
 * </pre>
 * 
 * This is really just a simpler way of creating a dynamic proxy. The
 * interesting (and useful) part comes when you also supply a <i>delegate object</i>.
 * The delegate can be any object, or even an array of objects. Whenever a
 * method is called on the mock object, CheesyMock will search the delegates for
 * a method with the same name and signature. If one is found, that method is
 * called and the results are returned to the caller. If no matching method
 * exists, the method call is a no-op and will return <code>null</code>. For
 * methods returning primitive types, a default value defined in the function
 * {@link CheesyMock#defaultReturnValue(Class)} will be used.
 * </p>
 * <h2>Example with delegate</h2>
 * <p>
 * In this example, we create a mock of
 * <code>javax.servlet.http.HttpServletRequest</code> and supply CheesyMock
 * with a delegate object capable of handling the method call
 * <code>getRequestURI()</code>. All calls to
 * <code>mockRequest.getRequestURI()</code> will return the value "/myURI".
 * 
 * <pre>
 * Object delegate = new Object() {
 *    public String getRequestURI() {
 *       return &quot;/myUri&quot;;
 *    }
 * }
 * HttpServletRequest mockRequest = CheesyMock.createMock(HttpServletRequest.class, delegate);
 * </pre>
 * 
 * </p>
 * <h2>Implicit JavaBean-style accessor methods</h2>
 * <p>
 * CheesyMock provides a shortcut for accessing <code>getXXX()</code> and
 * <code>setXXX(value)</code>-style methods; if the delegate has an instance
 * variable xxx, but no defined method getXXX()/setXXX(), it will treat the
 * delegate as if it actually had those methods, directly accessing the
 * underlying variable. Thus, you could simplify the previous example as:
 * 
 * <pre>
 * Object delegate = new Object() {
 *    public String requestURI = &quot;/myUri&quot;;
 * }
 * HttpServletRequest mockRequest = CheesyMock.createMock(HttpServletRequest.class, delegate);
 * </pre>
 * 
 * Or, as a one-liner:
 * 
 * <pre>
 * HttpServletRequest mockRequest = CheesyMock.createMock(HttpServletRequest.class,
 *    new Object() {
 *       public String requestURI = &quot;/myUri&quot;
 *    });
 * </pre>
 * 
 * </p>
 * 
 * @author fredrik.appelberg
 * 
 */
public class CheesyMock implements InvocationHandler {
	private static final Object[] EMPTY_ARGUMENTS = new Object[0];
	protected static final Logger log = Logger.getLogger(CheesyMock.class);
	private final Object[] delegates;
	private final Pattern getAccessorPattern = Pattern.compile("(is|get|set)([A-Z])(.*)");
	private final Class<?> baseInterface;
	private final CheesyMockDefaultBehaviour behaviour;

	/**
	 * Creates a new mock object.
	 * 
	 * @param interfaceToImplement
	 *            An interface to implement. This will be the expected return
	 *            type.
	 * @param additionalInterfaces
	 *            An array containing additional interfaces to implement. May be
	 *            null.
	 * @param delegates
	 *            An array of delegate objects. When searching for matching
	 *            methods, each delegate will be tried in that order until a
	 *            suitable method is found.
	 * @param behaviour
	 * @return
	 */
	@SuppressWarnings("unchecked")
	public static <T> T createMock(Class<T> interfaceToImplement, Class<?>[] additionalInterfaces, Object[] delegates,
			CheesyMockDefaultBehaviour behaviour) {
		CheesyMock mock = new CheesyMock(interfaceToImplement, delegates, behaviour);
		Class<?>[] interfacesToImplement;
		if (additionalInterfaces != null) {
			interfacesToImplement = new Class[additionalInterfaces.length + 1];
			interfacesToImplement[0] = interfaceToImplement;
			System.arraycopy(additionalInterfaces, 0, interfacesToImplement, 1, additionalInterfaces.length);
		} else {
			interfacesToImplement = new Class<?>[] { interfaceToImplement };
		}
		try {
			T obj = (T) Proxy.newProxyInstance(interfacesToImplement[0].getClassLoader(), interfacesToImplement, mock);
			return obj;
		} catch (ExceptionInInitializerError eiie) {
			log.error("Got ExceptionInInitializerError: " + eiie.getCause());
		}
		return null;
	}

	/**
	 * Convenience method: creates a new mock object.
	 * 
	 * @param interfaceToImplement
	 *            An interface to implement. This will be the expected return
	 *            type.
	 * @param delegate
	 *            A delegate object that will receive all method calls made to
	 *            the mock object.
	 * @return
	 */
	public static <T> T createMock(Class<T> interfaceToImplement, Object delegate, CheesyMockDefaultBehaviour behaviour) {
		return createMock(interfaceToImplement, new Class[0], new Object[] { delegate }, behaviour);
	}

	/**
	 * Convenience method: creates a new mock object.
	 * 
	 * @param interfaceToImplement
	 *            An interface to implement. This will be the expected return
	 *            type.
	 * @param delegate
	 *            A delegate object that will receive all method calls made to
	 *            the mock object.
	 * @return
	 */
	public static <T> T createMock(Class<T> interfaceToImplement, Object delegate) {
		return createMock(interfaceToImplement, new Class[0], new Object[] { delegate },
				CheesyMockDefaultBehaviour.ZERO);
	}

	/**
	 * Convenience method: creates a new mock object without a delegate.
	 * 
	 * @param interFaceToImplement
	 *            An interface to implement. This will be the expected return
	 *            type.
	 * @return
	 */
	public static <T> T createMock(Class<T> interFaceToImplement) {
		return createMock(interFaceToImplement, null);
	}

	protected CheesyMock(Class<?> baseInterface, Object[] delegates, CheesyMockDefaultBehaviour behaviour) {
		this.baseInterface = baseInterface;
		this.delegates = delegates;
		this.behaviour = behaviour;
	}

	/**
	 * Checks if two classes are equivalent; i.e. if the first is assignable
	 * from the other, or if the first is the primitive version (for example int
	 * instead of Integer) of the other.
	 * 
	 * @param cl1
	 * @param cl2
	 * @return
	 * @throws SecurityException
	 * @throws IllegalArgumentException
	 * @throws IllegalAccessException
	 */
	private boolean typeIsEquivalent(Class<?> cl1, Class<?> cl2) throws SecurityException, IllegalArgumentException,
			IllegalAccessException {
		if (!cl1.isPrimitive() && cl2 == null) {
			return true;
		}
		if (cl1.isAssignableFrom(cl2)) {
			return true;
		} else if (cl1.isPrimitive()) {
			try {
				Field typeField = cl2.getField("TYPE");
				if (cl1.equals(typeField.get(cl2))) {
					return true;
				}
			} catch (NoSuchFieldException e) {
			}
		}
		return false;
	}

	/**
	 * Find the method in the given class that matches the given method name and
	 * signature.
	 * 
	 * @param cl
	 * @param name
	 * @param signature
	 * @return
	 * @throws NoSuchMethodException
	 * @throws SecurityException
	 * @throws IllegalArgumentException
	 * @throws IllegalAccessException
	 */
	private Method searchMethods(Class<? extends Object> cl, String name, Class<?>[] signature, Class<?> returnType)
			throws NoSuchMethodException, SecurityException, IllegalArgumentException, IllegalAccessException {
		Method[] methods = cl.getMethods();
		for (Method method : methods) {
			if (name.equals(method.getName())
					&& method.getReturnType().equals(returnType)
					&& ((signature == null && method.getParameterTypes().length == 0) || Arrays.equals(signature,
							method.getParameterTypes()))) {
				return method;
			}
		}
		if (signature == null) {
			signature = new Class<?>[0];
		}
		for (Method method : methods) {
			if (name.equals(method.getName()) && method.getReturnType().equals(returnType)) {
				Class<?>[] parameterTypes = method.getParameterTypes();
				if (parameterTypes.length == signature.length) {
					boolean found = true;
					for (int j = 0; j < parameterTypes.length; j++) {
						if (!typeIsEquivalent(parameterTypes[j], signature[j])) {
							found = false;
							break;
						}
					}
					if (found) {
						return method;
					}
				}
			}
		}
		throw new NoSuchMethodException();
	}

	public Object invoke(Object obj, Method method, Object[] arguments) throws Throwable {
		if (log.isDebugEnabled()) {
			log.debug("Invoked method " + method.getName());
		}
		return invokeImpl(obj, method, arguments);
	}

	private Object invokeImpl(Object obj, Method method, Object[] arguments) throws IllegalAccessException, Throwable {
		CheesyMock invocationHandler = (CheesyMock) Proxy.getInvocationHandler(obj);
		for (Object delegate : invocationHandler.delegates) {
			if (delegate instanceof CheesyCallback) {
				CheesyCallback callback = (CheesyCallback) delegate;
				try {
					callback.invoke(method, arguments);
				} catch (RuntimeException rex) {
					log.error("Intercepted exception in CheesyCallback delegate: ", rex);
				}
			}
		}
		Class<?>[] argClasses = null;
		StringBuilder argList = new StringBuilder();
		if (arguments != null) {
			argClasses = new Class[arguments.length];
			for (int i = 0; i < arguments.length; i++) {
				if (arguments[i] == null) {
					// argClasses[i] = Object.class;
					argClasses[i] = null;
					argList.append("Object");
				} else {
					argClasses[i] = arguments[i].getClass();
					argList.append(argClasses[i].getName());
				}
				if (i < arguments.length - 1) {
					argList.append(",");
				}
			}
		} else {
			arguments = EMPTY_ARGUMENTS;
		}
		if ("equals".equals(method.getName())) {
			// if(arguments[0] instanceof Proxy) {
			// ((Proxy)arguments[0]).
			// }
			// FIXME: vi behöver en mer genomtänkt hantering av equals()
			return obj == arguments[0];
		}
		Matcher matcher = getAccessorPattern.matcher(method.getName());
		String fieldName = null;
		boolean isGetAccessor = false;
		boolean isSetAccessor = false;
		if (matcher.matches()) {
			if (argClasses != null && argClasses.length == 1 && Void.TYPE.equals(method.getReturnType())) {
				fieldName = matcher.group(2).toLowerCase() + matcher.group(3);
				isSetAccessor = true;
			} else if (argClasses == null && !Void.TYPE.equals(method.getReturnType())) {
				fieldName = matcher.group(2).toLowerCase() + matcher.group(3);
				isGetAccessor = true;
			}
		}
		Object returnValue = null;
		boolean foundValidMethod = false;
		for (Object receiver : delegates) {
			if (receiver != null) {
				try {
					try {
						Method receiverMethod = searchMethods(receiver.getClass(), method.getName(), argClasses, method
								.getReturnType());
						if (log.isDebugEnabled()) {
							log.debug("Found method " + method.getName() + "(" + argList.toString() + ") in receiver "
									+ receiver);
						}
						receiverMethod.setAccessible(true);
						returnValue = receiverMethod.invoke(receiver, arguments);
						foundValidMethod = true;
						break;
					} catch (NoSuchMethodException nsme) {
						if (log.isDebugEnabled()) {
							log.debug("No method " + method.getName() + "(" + argList.toString()
									+ ") found in receiver object " + receiver);
						}
					}
					if (fieldName != null) {
						try {
							Field field = receiver.getClass().getDeclaredField(fieldName);
							field.setAccessible(true);
							if (isSetAccessor) {
								field.set(receiver, arguments[0]);
								foundValidMethod = true;
							} else if (isGetAccessor) {
								returnValue = field.get(receiver);
								foundValidMethod = true;
							}
							break;
						} catch (NoSuchFieldException nsfe) {
							if (log.isDebugEnabled()) {
								log.debug("No field '" + fieldName + "' found in receiver object " + receiver);
							}
						}
					}
				} catch (InvocationTargetException ite) {
					throw ite.getCause();
				} catch (UndeclaredThrowableException ute) {
					log.error("Got UndeclaredThrowableException while invoking " + method.getName() + "("
							+ argList.toString() + "), the cause given was: " + ute.getCause());
				} catch (SecurityException se) {
					log.error(se);
				}
			}
		}
		/*
		if (Void.TYPE.equals(method.getReturnType())) {
			return null;
		}
		*/
		// Handle default toString method
		if (!foundValidMethod && "toString".equals(method.getName()) && arguments == EMPTY_ARGUMENTS
				&& String.class.equals(method.getReturnType())) {
			return baseInterface.getName() + "@" + Integer.toHexString(hashCode());
		}
		if (foundValidMethod) {
			return returnValue;
		}
		return behaviour.defaultReturnValue(method);
	}
}
